(2024/04/06更新) 因應React在18後更新了許多不同的語法,更新後的教學之後將陸續放在 新的blog 中,歡迎讀者到該處閱讀,我依然會回覆這邊的提問
reference,中文翻譯是「參考」。聽起來好像有點奇怪,但他在程式中一般是指「變數指向的記憶體位置上對應到的值」。
超級複雜的啦。
簡單來說可以想像成是房子跟地址的關係。記憶體就像是地址,變數對應的值就像是房子,「沿著地址找到房子」這個過程就是reference。房子本身可能會有很多內部變動,但不管怎麼變,房子所在的地址是不變的。
在Javascript變數中,物件和Array一般會是以類似reference的方式來傳遞,其他的變數通常會複製一份後,把複製出的那一份拿來傳遞。
更正確的原理可以參考Huli大大的文章:
當程式大起來,網頁中的元素很多,當想要用原始DOM api去操作元素時,卻還要用document.querySelector
或是document.getElementById
去整個網頁找,就顯得很不直覺。
能不能直接在JSX中取得元素的reference,直接操作元素本身呢 ?
也就是說,理想上我們希望做這種事情
用一個變數去綁在元素的props上,然後就能讓該變數等於綁定元素的reference
大概是這樣(實際上當然不能直接這樣做):
import React, {useRef} from "react";
const InputForm=()=>{
let accountRef = {};
let passwordRef = {};
let refArr = [accountRef,passwordRef];
return (
<>
<input
type="text"
name="account"
ref={accountRef}
/>
<input
type="text"
name="password"
ref={passwordRef}
/>
<button onClick={()=>{
refArr.forEach((item)=>{
console.log(item.name+" is "+item.value);
})
}}>提交</button>
</>
)
}
export default InputForm;
過去,React在class component中的確有提供React.createRef()
這個API來創造一個可以讓你綁在ref
這個props上的object變數。讓你能直接拿到該元素本身、直接用原始DOM方式操作元素。
但是這個API如果直接拿到function component來用會有問題。原因是React.createRef();
通常只會在class component的建構子呼叫一次,這樣就能確保這個創造出來的reference指向的是同一個地址。然而function component沒有建構子,每次都一定會重新呼叫function component的定義域,這樣等於每次都會重新創造一次這個object變數,賦予值被重新初始化,指向的reference也會不一樣了。
為了解決這個問題,React提供了另一個React hook - useRef
。
useRef
是一個函式,跟useState
一樣接收一個參數,作為變數初始值。差別是useRef
回傳的是一個物件,裡面只有一個屬性current
:
const data = useRef("初始資料")
console.log(data)
// { current: "初始資料" }
React會確保useRef
回傳出來的這個物件不會因為React元件更新而被重新創造。也就是說在你初始化過後,這個物件會始終指向同一個reference。
請注意雖然物件本身指向位置一樣,但如果你重新assign物件中current屬性裡面的值,那current對應的value指向的東西就會不一樣。
也就是說剛剛的「理想」只要引入useRef
後,只要先創造要綁在input的propsref
上的變數,綁定之後,變數名稱.current
就會是該input元素本身,我們就能用直覺的方式操作DOM元素了!
// 引入useRef
import React, {useRef} from "react";
const InputForm=()=>{
// 建立用來綁定input的變數
const accountRef = useRef(undefined);
const passwordRef = useRef(undefined);
// 為了方便操作,建立一個array來管理這些ref
const refArr = useRef([accountRef,passwordRef]);
return ( // 將剛剛創立的變數綁在對應的位置
<>
<input
type="text"
name="account"
ref={accountRef}
/>
<input
type="text"
name="password"
ref={passwordRef}
/>
<button onClick={()=>{
refArr.current.forEach((item)=>{
console.log(item.current.name+" is "+item.current.value);
})
}}>提交</button>
</>
)
}
export default InputForm;
由於useRef「不會因為update元件而被改變reference」的特性,讓其常被用在這些地方:
上面講過了
如果用一般變數來當counter,元件被update的時候又會被重新初始化,就無法達到計數的效果。
因為要reference一樣才能正常移除函式,但這件事在callback函式不需要和state/props有關時也可以用useCallback做(後面會講這個是啥)。雖然沒有特別規定,不過有人會認為useCallback在閱讀時會更直覺聯想到是函式。但是如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。
const mounted=useRef(false);
useEffect(()=>{
if(mounted.current===false){
mounted.current=true;
/* 下面是 第一次渲染後 */
/* 上面是 第一次渲染後 */
}
else{
/* 下面是元件更新後 */
/* 上面是元件更新後 */
}
return (()=>{
/* 下面是元件移除前 */
/* 上面是元件移除前 */
})
},[dependencies參數]);
如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。
請問為什麼當callback函式需要和state/props有關時不能使用useCallback呢?
Hi,這是因為removeEventListener
是透過傳入的函式本身的reference去清除callback。而useCallback
在傳入其第二個dep參數內的state/props有變動時會重新定義函式。我們來看一個比較極端的例子:
import {
useState,
useCallback,
useEffect,
useRef
} from "react";
export default function App() {
const ref = useRef(undefined);
const [ctn, setCtn] = useState(0);
const handleWindowResize = useCallback(() => {
// 為了觀察,故意用不好的寫法
setCtn(ctn+1);
}, [ctn]);
useEffect(()=>{
// 為了觀察,故意違反嚴格模式
ref.current = handleWindowResize;
window.addEventListener("resize", handleWindowResize);
},[])
useEffect(() => {
// 為了觀察,故意違反嚴格模式
console.log(ref.current === handleWindowResize);
}, [ctn]);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<button>Start editing to see some magic happen!</button>
</div>
);
}
實際執行拉動螢幕,你會發現React印出了兩個值:
true
false
第一個true
是在第一次渲染畫面後執行,所以印出這個值很正常。但是第二次為什麼印出false
呢?這是因為在第二次渲染畫面前,useCallback
發現其dep的ctn
被改變,所以handleWindowResize被重新定義、reference和addEventListener
時不一樣了。
另外一件奇怪的事情是不論你怎麼拉動螢幕,React都只印了兩次結果。這是因為我們起始傳入addEventListener
的函式是把ctn從他原本的0變成1。當ctn被改變時,重新定義過的handleWindowResize並沒有被傳入addEventListener
。在舊的handleWindowResize定義下,React只是不斷的讓ctn變成「初始的ctn值加1」。這導致ctn在1之後就不再被改變過,useEffect不再觸發。
上面這個例子告訴我們,當在useCallback
中使用state
時,我們會無法使用removeEventListener
清除該函式。這會導致memory leak或是非預期的錯誤。當然,你也可以違反嚴格模式,不在useCallback的dep中加入使用到的state。只是如此一來,在useCallback中定義的函式只會使用到state在建立元件時的初始值,不會隨著該state變動而改變定義內容,這樣就不需要使用state了。
請問您這裡所說的 callback函式
,是指會使用addEventListener
去指定的函式嗎?
我在此系列第20天有看到有用到state的useCallback,不一樣的是直接放在onClick中
const handleClick = useCallback(() => {
console.log("isOpen is " + isOpen);
},[isOpen]);
是。在這篇中也是一樣的意思。所以這段話是針對removeEventListenser
、clearITimeout
、clearInterval
去註解可能遇到的狀況
- addEventListener(removeEventListenser)、setTimeout(clearITimeout)、setInterval(clearInterval)
因為要reference一樣才能正常移除函式,但這件事在callback函式不需要和state/props有關時也可以用useCallback做(後面會講這個是啥)。雖然沒有特別規定,不過有人會認為useCallback在閱讀時會更直覺聯想到是函式。但是如果你的callback函式需要和state/props有關時,就要用useEffect搭配useRef實作。
至於JSX/React element的onClick
,它是React提供的合成事件,我們不需要去清除,自然也不會有上述的問題。可以透過useCallback去定義有state的函式、避免不必要的渲染,沒問題的。
大概理解囉,感謝您,您的文章真的很好~